iT邦幫忙

2024 iThome 鐵人賽

DAY 5
3

The bugs of Hallownest were twisted out of shape by that ancient sickness. First they fell into deep slumber, then they awoke with broken minds, and then their bodies started to deform...

-- in game Hollow Knight, by The Hunter

... 人的創造並沒有任何神秘之處,這奇蹟只是意志創造出來的。然而我們至少可以說,真正的創作必含有其祕密。誠然,作者很可能只是繞著同一個思想創作相近的一系列作品,但我們也可以設想另外一種創作者,是以並行而獨立的方式來創作。他們的作品看起來似乎彼此沒有關聯,甚至某種程度上還互相牴觸。但是整體觀之,就可以看出這些作品之間的設定安排。

-- <薛西弗斯的神話>,卡繆著,嚴慧瑩譯

其實有很多可以選擇的方案,因為桌遊之間其實共用很多機制。大部份物件、回合、事件的概念,也都可以從諸多遊戲當中抽象化出來。比方說,我層與深顏色工作室私聊關於數位化的想像,他們提過讓疫途能夠上到 BGA 去。當時我沒有印象官方有開放開發,但是現在顯然有了,所以有興趣開發桌遊實作的讀者朋友可以往這個方向走。另外,根據 BGG 的消息,疫途在 Tabletopia 已經有實作了!有趣... 希望我 train 好之後可以拿來挑戰那些天梯上的玩家。

Wow,在搜尋的時候剛好發現這篇討論,非常精彩,關於如何訓練出專門玩好遊戲的 AI,又需要怎麼樣的通用格式來紀錄、描述盡可能任何多的遊戲?這是 AlphaGo 剛擊敗李世乭的時候了,所以很多當時討論的人還無法想像 LLM 的能力吧。

Wow2,今年的鐵人賽也有桌遊相關系列文,是阿鵝大大的透過實作網頁遊戲練習網站工程師的基本素養,以 San Juan(聖胡安) 為例。酷!同步支持。

不得了,還有通用遊戲(general game playing) 的研究!

考量到這是一個以訓練強化學習代理人為目標的專案,就有資料的需求,而 BGA/Tabletopia 目前沒有釋出對局資料供訓練。仍然有其他選擇,如按照昨天也提過的 OpenAI Gym 的格式去開發遊戲;只不過這是將抽象層定位在環境的設計,因此只有 stepreset 這種層級的 API,而不會有桌遊的概念。

或許有讀者會不同意資料需求的部份。我無意反駁,但切分模擬--訓練--評估的階段,感覺起來還是比較穩當。如果所有對弈的模擬都在 online 訓練,不是會有不穩定的狀況嗎?

最適合的也許是 TabletopGames 這套框架,主打為了 AI 研究而設計的,目前看起來支援項目從最古老的 21 點撲克牌戲到最新的(竟然!)是殖民火星。可惜我是在專案進行了好一陣子的今年初才發現這個框架,否則應該可以簡化一些功。不,大概不會吧,因為我已經打算藉著這個機會學點 Rust。

基本資料結構

在之後的系列文內容,我可能會交錯使用遊戲系統和遊戲引擎兩個詞。

簡單帶過,也簡單介紹我對這些棋盤物件常用的操作。

這些雖然是 Rust 標準函式庫內建的型別,但可以想像內部實作應相當複雜,所以在轉換這些遊戲狀態給強化學習代理人的時候呼叫的編碼函數,是佔據執行時間 6% 的大部位。

pub struct Game {
    pub env: HashMap<Coord, World>,
    pub map: HashMap<Camp, Coord>,
    pub character: HashMap<(World, Camp), Coord>,
    pub stuff: HashMap<Coord, (Camp, Stuff)>,
    pub turn: Camp,
    pub phase: Phase,
    // Given history, given empty tree for recording, or None
    pub history: Rc<RefCell<TreeNode>>,
    pub savepoint: bool,

    rng: Rc<RefCell<StdRng>>,
}

前四個成員是遊戲本身的物件,並非抽象的安排;turnphase 是為了實作方便而引入的;最後的幾個項目則是各有特殊考量,這裡就不多提。簡單舉兩個遊戲物件展開,順便也補充一下規則的吉光片羽給沒有接觸過本遊戲的讀者們。

env:人間?冥界?

設計之時的想法是,我不太可能取得一個座標之前就已經知道那個棋盤格是屬於哪個世界,所以這裡使用 HashMap 由座標(struct Coord)對應到世界(enum World)。由於我以往還是大部份在和 C 語言打交道,沒怎麼用過這麼方便的東西,熟悉了一陣子。當然也可以使用 BTreeMap,以遊戲的尺度來說資料量都很小,也許該評估一下。常用方法是 insertget,幾乎只使用這兩種。

insert 新增一筆對應的資料到整個物件裡面。這是盤面初始的設定,用例只會出現在最一開始 Setup0 階段:

// Example SGF content
// ...
//	;C[Setup0]AW[fa][db][fe][cd][be][ff][dd][cb][cf][ae][ed][ec][de][ea][bb][af][dc][ba][ac]
// 	;C[Setup0]AB[bf][eb][fd][aa][cc][da][ef][ca][fc][bd][bc][ab][ce][fb][ad][df][ee]
// ...
...
                    if g.phase == Phase::Setup0 {
                        let mut h: Vec<String> = Vec::new();
                        let mut u: Vec<String> = Vec::new();
                        t.get_general("AW".to_string(), &mut h);
                        t.get_general("AB".to_string(), &mut u);
                        for c in h.iter() {
                            g.env.insert(c.to_env(), World::Humanity);
                        }
                        for c in u.iter() {
                            g.env.insert(c.to_env(), World::Underworld);
                        }
                        if g.is_setup0_done() {
                            g.phase = Phase::Setup1;
                        }

t 是此時的 SGF 樹節點,透過自行撰寫的 get_general 展開裡面所有的項目,至放在傳入的可寫向量物件 hu 當中。這個變數名稱大致採取以往在 Golang 的習慣,不會混淆的型別的物件的話直接以單個首字母命名物件,倒也簡單。中間的迴圈將個別的座標值放在 c,透過 to_env 轉換成棋盤座標(而非羅盤座標!)之後,安插對應到人間還是冥界的關係回到 env 物件裡。

除了起始階段有設置的需求,再來就是很多的查詢。比方說,在玩家移動角色的時候,作為遊戲系統,必須要能夠確定玩家不會錯誤地跨越人間與冥界。查詢,對我來說比較常多付出 O(1) 的代價在剛改完程式碼、編譯下去的時候,因為 get 方法取得索引的方式是用參照型而非原型,而我時常忘了加 & 取參照。一樣以起始階段的這一段為例

...
pub fn setup_with_coord(&mut self, c: Coord) -> Result<Phase, &'static str> {
...
            Phase::Setup2 => {
                let w = *self.env.get(&c).unwrap();
                match self.character.get(&(w, self.turn)) {
                    Some(_) => {
                        return Err("Ex1e");
                    }
                    None => {
                        self.character.insert((w, self.turn), c);
                    }
                }

Setup2 是雙方輪流置放自己的兩個棋子的階段。醫療方和疫病方各有一枚在人間與冥界的棋子。所以上面的片段,就是透過 &c 查詢該座標屬於哪個世界。雖然還沒提到,但是下一行的 match 區塊,就藉著這個新取得的 w 資訊與 self.turn 陣營資訊兩者的組合(tuple)、的參照,去查找該位置是否已經存在任一棋子。若有(Some),則進入無論是有什麼(_)的上半,回傳一個錯誤碼;若無(None,Rust 的 Option 物件的歸零值),就可以使用前述的 insert 方法設置。

其實這個傳參照的動作應該不需要死背,而應該是要好好理解 Rust 的借用概念,從而可以導出這些變數的所有權在每一次的函數呼叫之間的流轉。但是,真是抱歉,工具、編譯時期的輔助、社群資源、手冊的說明、甚至大型語言對於 Rust 的理解再轉譯,都實在是太強大,所以我時不時還是會無意識地寫,然後付出編譯錯誤再修正的一些時間成本。

map:雙方各執一子,互為起源與目的

既然都是 HashMap,那也沒有太多不同的用法,以下一樣是簡略地夾帶遊戲本身與現行程式碼聊聊。

同樣可以參考一個設置階段的用法,

    pub fn setup_with_coord(&mut self, c: Coord) -> Result<Phase, &'static str> {
        match self.phase {
            Phase::Setup3 => {
                self.map.insert(Camp::Doctor, c);
                self.map.insert(Camp::Plague, c);
                self.phase = Phase::Main(1);
                Ok(Phase::Main(1))
            }
...

這就有趣了。按照規則,最後一個設置階段,是由醫療方先放羅盤上的標記,棋局標準回合由此正式開始。疫病方的羅盤標記,是需要等到該玩家的第一手,才會放置。但這裡我卻直接連續呼叫兩個 insert,且程式碼的語意也很清楚,就是將同一個座標 c,先後透過這兩行指定給兩個陣營。這是為什麼呢?

這是試誤(trial-and-error)的故事,並非我一開始就如此規劃。總之,當初也是直接按照規則實作,結果在某些時候(會在之後的文章描述到相關的故事),會因此發生問題,在標準回合的羅盤階段

    pub fn add_map_step(&mut self, g: &Game, c: Coord) -> Result<&'static str, &'static str> {
        assert_eq!(self.action_phase, ActionPhase::SetMap);
        if *g.map.get(&g.opposite(g.turn)).unwrap() == c {
            return Err("Ex00");
        }
        if *g.map.get(&g.turn).unwrap() == c {
            self.action_phase = ActionPhase::Done;
            return Ok("Ix00");
        }
        if !c.is_valid() {
...

慚愧,寫作的此時,這裡還有很多註解根本就該清掉。還有,第三段的 c.is_valid 論嚴重性,根本就該放在最前面來處置。但目前也不會特別去動它吧。請容我將他們自上述引述程式碼中刪去。

這個函數 add_map_step 即是希望針對現在的遊戲盤面 g,以及玩家或代理人企圖選定的著點 c 座標,判斷出這是不是一個合理的行動。這個函數裡面包含很多判斷和處理,但這裡節錄前兩個判斷式就可以說明前面未解的疑問。第一個是,取得當前玩家(g.turn)的敵對陣營(g.opposite)的位置,是否和現在玩家企圖使用的著點相同。如果相同的話,就是一個相衝的狀態,必須要回傳錯誤(Err("Ex00"))。假設我們是進行第一手的疫病玩家,故意去下當時已經存在的醫療方在羅盤上標記的座標,那就會觸發這個區塊。

如果這個部份沒事,則會進到下一組。這次是取得自己現在的標記物所在座標,以判斷是否相同。規則上明定必須移動標記,所以這裡如果相同的話,當然是需要判斷的。讀者諸君可以觀察到我使用不同的狀態碼,這些其他細節將在後續的系列文補述。

目前狀況

呃,坦白講,其實過去這半年都是如此,周間一、二、三可能還有點能量回顧自己的專案,但到了週三也差不多被工作消耗完,以至於四、五大概不會有什麼產出。對,今天也沒有收集對局資料,也沒有新的資料訓練新的模型。

為什麼甘願這樣浪費時間?距離比賽結束只剩下 600 小時多一點點,非常珍貴的時間。但是,現在的問題很明顯。一般的機器學習任務,可以計算正解率:狗就是狗,判成貓就是錯。但是強化學習,代理人從一盤盤的低品棋局,到底精鍊出了什麼?針對每個階段資料集訓練,損失越小,就是學到越多嗎?這裡的確我有蠻多臆測,但我總之希望靠著這份直覺前進看看。

我希望可以讓它有殘局可以學。是的,這已經開始偏離 AlphaZero 的主要概念,但是我已經有點受夠這種模稜兩可的迴圈。我希望從過去已經下了數十萬盤的對局棋譜的最後一手棋,做出一手詰殘局譜。同時我也會更看中它隨著訓練過程的反應,當然也應該做些實驗將它分配到訓練或是驗證集當中,看看效果如何。

所以,正在實作中。如果順利的話,明天就可以繼續更新訓練進度,而這個 tsume_generator 的詳細就可能留待系列文的後續;如果不順利的話,那也許就補充些實作過程的細節吧。


上一篇
過程中拋棄的點子
下一篇
隨機猴子和 bug fix
系列文
DeltaPathogen:國產雙人不對稱抽象棋「疫途」之桌遊 AI 實戰26
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言